bar-mission-kit: mission DSL recognizer, validator, and live editor service - #54
Draft
keithharvey wants to merge 94 commits into
Draft
bar-mission-kit: mission DSL recognizer, validator, and live editor service#54keithharvey wants to merge 94 commits into
keithharvey wants to merge 94 commits into
Conversation
Editor architecture milestone 2 (editor_architecture_plan.md): the subset recognizer, defined over full_moon's lossless CST — the same parse layer bar-lua-codemod already uses, per the plan's own recon instruction (no second parser, no tree-sitter needed). The walk IS the grammar: it classifies statements into trigger chains (T.When ... .Register, dot-only, closure-free), verb expressions (dotted path + chained invocations), literals, named refs, and tables; everything else is an opaque span with a finding. Comment decorators are promoted to structure: ---@group parents following chains into sections, ---@Label titles a chain. Every node carries byte spans for the write-back milestone. Two consumers of one walk: bar-mission-kit parse <dir> --out ast.json the decorated AST bar-mission-kit check <dir> CI validator, nonzero on findings 6 unit tests incl. the real hello_pawns mission shape, chained invocations, closure rejection, decorator grouping. hello_pawns triggers check clean end to end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…emod's Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One Lua front-end across the stack: the same parser family as emmylua_check/emmylua_ls — the type layer the annotations-as-schema claim rides on, and the language-server runtime the editor track ends at. full_moon is out of the kit (the codemod still uses it; porting that is its own change while bar_fmt is pre-release). The walk inverts with the CST shape (emmylua nests calls in prefixes where full_moon lists suffixes): unroll() flattens call/index chains into path + invocations with explicit pending-name state — which also fixes a latent stash bug the suffix-order version masked (F()() call merging). Literal values now come from the parser's own analyzers (proper string unescaping, int/float discrimination) instead of hand-trimming. Decorators read from source lines above each chain — span-based, no comment-API dependency. Same JSON contract, CLI, and recipes; 6/6 tests, hello_pawns checks clean, artifact regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The language-server role over the filesystem transport (editor_architecture_plan.md: the filesystem is the entire interface): missions/**.lua --watch--> mission_ast.json (file -> UI, generation counter) editor/edits/*.json --apply--> missions/**.lua (UI -> file, span edits) editor/open_request.json --> code -g file:line (mode switch to code) Every write goes through the recognizer first: an edit whose result fails to parse or leaves the mission subset is REJECTED, the file untouched, the reason published in status.json — the grammar is the write gate. Paths are confined to the missions dir. Polling watcher (300ms, mtime+size fingerprint) — no inotify dependency across container mounts. Model grows generation (poll target) and per-trigger line (open-in- editor). just bar::mission-serve builds release and runs on the host (where VS Code lives). 10 tests: 4 new for the write gate — valid span edit applies, grammar-breaking edit rejected byte-identically, parse-breaking edit rejected, path escape rejected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The widget's io writes and VFS reads resolve against the write dir (~/.local/state/Beyond All Reason on Linux), not the repo — serve must meet it there for both the artifact and the intent channel. BAR_WRITE_DIR overrides; repo fallback for headless-style setups. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mode toggle is narrative (who owns the pen); safety is mechanical: FileAst carries an FNV-1a hash of the source it was built from, edit intents send it back as base_hash, and apply_edit refuses to write if the file changed since — the regeneration loop then refreshes the stale view instead of clobbering. VS Code's own dirty-buffer prompt is the third leg. base_hash is optional for tool callers; the form UI will always send it. 11 tests (stale-hash refusal added, byte-identical file preserved). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mission_graph.dot derived from the AST: triggers as boxes, objectives as ellipses, VICTORY/DEFEAT terminals; edges follow objective state (Complete feeds IsComplete watchers). The editor-plan stage-2 graph as data — renderers attach later without re-deriving. 12 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tree walk now knows what things MEAN: UnitDef() string args are stamped unit_def_name, Objective() args objective_name (and collected per file for dropdowns), Has counts count. The artifact embeds the authoring-surface schema (surfaces/missions.json — curated overlay today, the slot annotation-derived data lands in later) and each trigger carries insert_effect_at, the byte offset where an added .Do(...) line belongs. Invocation spans now cover the args list, not the nested prefix chain (emmylua CallExpr includes its prefix — the Register 'call span' was the whole chain). 13 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FileAst.insert_trigger_at (EOF) so the form can append whole chains; gate test proves an appended T.When(...).Do(...).Register() statement passes the recognizer and lands as a second trigger. 14 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trigger.remove_span (whole chain's lines) and Step.remove_span (the step's source line) — the spans the form's remove/decomposition controls replace with nothing. The recognizer gate keeps removal legal (deleting the last Do is refused, not silently broken). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trigger.insert_condition_at — past the When line's newline, where an inline '+ and when' adds an .AndWhen(...) row to an existing chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Serve belongs to mission-editing sessions, not every launch: the wrapper starts mission-serve in the background, launches the game, and kills serve when the session ends — the start-together die-together lifecycle that prevents the stale-serve panel confusion, without taxing plain bar::launch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chains root at bare When (no T), end at their last call (no Register), and legality means at least one Do. Repeated .When AND-composes as a chain step; a leftover Register gets a named finding pointing at the migration. Effect inserts append past the chain's last line. 15 tests; the live hello_pawns mission checks clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
just bar::mission-restack [rebase|iterate|test|push|all]: rebases the mission api stack (hello_pawns -> matchflow_extraction -> bar_editor) from upstream/master with the one known auto-resolution (the extraction layer's matchflow_verdict deletion wins), regenerates modules_iteration as a DERIVED branch (sharing_tab + hello_pawns — rebuilt every time, never hand-edited, the sharing-module philosophy), and gates on the busted suite. Any unexpected conflict aborts loudly. push force-pushes the stack + iteration to upstream. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ser terminal One renderer, three dumb terminals. The view artifact carries the form markup, modal vocabulary, and a live-probe manifest; state.json flows back armed-gated. HTTP is an adapter over the same files.
…hello_pawns foundation
EmmyLua sees a string there and has nothing to say — which unit defs exist is decided by loaded content, so no static type can hold it. Our findings are the layer over that, and they now carry the byte span of the token at fault, so the squiggle sits under "corlt" rather than the whole statement. Findings ride the status payload as data as well as prose: message stays the human reading, and the structured list carries path, line and span. An editor without the list falls back to the line-wide reading. Severity is warning, not error: the mission still loads and runs, it just never fires the condition.
The extension existed to make BAR-Devtools unnecessary, but it still required someone to run `just bar::mission-serve` in it first — the dependency had moved, not gone. It now ships the binary and starts one when nothing answers. Adopt before spawn. One server feeds this panel, the browser terminal and the in-game view, and a second writer into the same .editor directory would race the first over edit intents — so a reachable server is used as-is, and only ours is stopped on deactivate. It works out where to serve from: modules/missions under a workspace folder (or one level into a multi-repo checkout), publishing into the engine write dir so the in-game panel reads the same artifact. Both are overridable when the guess is wrong.
Renaming a verb in the game left the kit describing the old shape, so the form fell back to rendering raw DSL — a good fallback, and a silent wrong answer. The vocabulary was written down in three places: the game's types, a merged snapshot here, and the surface overlay. Only the first is authored. The snapshot is now a mirror of the game's files, one per module, byte for byte, with a script to refresh and a --check the kit's tests run. Merging them was the reason drift could not be seen: two modules' types fused into one file cannot be diffed against either module. The overlay's template is renamed with it. That file is still hand written, so it stays a place drift can hide.
Every verb a module publishes now becomes a palette entry, with the example call built from its signature. The templates were hand-written beside the types they describe, so a renamed verb left an entry nobody could run — and the list could only ever be as complete as whoever last edited it. Combat.Unprotect had been published for some time and was never in the palette; it is there now without anyone adding it. The overlay keeps what a signature cannot imply: a label when the humanised path reads badly, and compositions like Protect ... Until. All ten derived templates match the ten that were written by hand, which is what makes the old file redundant rather than merely duplicated.
Spawns get an add row and, through the same trigger card as everything else, a remove. The four spawn templates are curated rather than derived: a map fraction is a `number` to the type system, so nothing in the signature says where on the map a new unit belongs.
The panel is as often open in the docs or devtools window as in the game checkout, and there it found no modules/missions, so it never started the server it ships and told the user to run mission-serve instead. Look in the workspace, then beside it, then where the game installs; and when there is still nothing, say that rather than name a command.
Amending a branch mid-stack orphans everything above it — which is the state you run rebase to fix, and the ancestry guard rejected it. Status and push still refuse, since there a break means the stack is not the one described.
A module publishing new vocabulary landed in fixtures/ and stayed invisible to the kit, because the include_str! list beside it was hand-written. The sync script owns both now, and --check fails on either drifting. Ordering is part of the contract: the parser resolves forward references positionally, so missions leads (it declares the base classes) and a module's dsl.lua parses after its mode_dsl.lua.
cp onto the binary the running extension is executing is ETXTBSY, so a reinstall failed for anyone whose editor had already started the bundled server - which is everyone it works for.
One "@meta dsl" marker for two grammars meant a global declared in both resolved to whichever file was read last — and the two readers disagreed on that, so the compiled snapshot and the module explorer gave different answers for Transfer. Files now say mission_dsl or mode_dsl, the two parse apart, and a module card shows both without either shadowing the other. Both grammars name the same module actions; they differ in what they say about them. "policy" stays what policies/ means: the runtime guard. Also the sentences for verbs that had none, and a note on why the receiving team cannot be a slot.
A module declares what it can do once, as a class that is callable where it can be performed (---@overload) and carries variants where a grant can be narrowed. A field typed with that class resolves to its call signature, so .Allow(Transfer.Units) and Do(Transfer.Units(...)) are the same entry rather than two that happen to agree — the mission facet comes out as an effect, the mode facet as a noun, from the one file. Files carrying "---@meta actions" publish into both sandboxes; the sync script mirrors them, and prunes fixtures the game has deleted, which it was quietly keeping.
Resolving an action's mode facet swept up every non-fun field with it, so Team.Player.allyTeam and Unit.name turned up in the editor's noun list. A field typed integer is the shape of a handle, not something to name.
The markers were a tier word and two instance words: "actions" says what a declaration IS, "mission_dsl" said which module hosts it. A composable system cannot enumerate its languages in the kit. A file now says "actions" — what a module can do, read by every language — or "policy <language>" — how rules over those actions are written, read by that one. A trigger says when to perform an action; a mode says whether it may be performed. Same tier, different languages. Hosting a new one costs a module a file and costs this crate nothing.
keithharvey
force-pushed
the
bar_editor
branch
2 times, most recently
from
July 28, 2026 05:20
619ee53 to
eddf22c
Compare
9 tasks
The waves and scavengers modules publish mission vocabulary, so the kit mirrors their types and derives the palette from them like any other module. The sync also picks up construction, which had drifted out of the mirror. Labels and one curated composition. Begin only reads as itself once it is aimed and placed — .Against is required, and no signature can say that — so the composed call joins Protect-until as a palette extra. Live probes are the real work. A wave condition is a counter against one director, and the pack is a bare reference (Scavengers.Skirmish) rather than a string, so the probe reads the name node and lowercases it into the name the director publishes under. That indirection is the point: a mission names a pack, and never learns that scavengers happen to keep their rulesparams under a "scav" prefix inherited from the spawner they replaced.
Reading progression meant reading chips one at a time. Each trigger card now carries the identity the runtime stamps it with, so a front-end can shade the whole card once the engine has actually fired it. Fired, not "its condition is true right now". A once-trigger stays fired after its condition goes false, so shading off the live condition would light up and then flicker back — and a trigger whose conditions have no probe at all (MatchFlow.Started) would never light at all. Its own attribute, deliberately. The front-ends write TEXT into a data-live element, which would erase the card, and the name avoids the data-live substring because the billboard asserts it carries none. It is emitted only for the interactive form for that same reason. The dedup test now counts chip probes rather than every probe: card probes are a second channel and have their own test.
Nothing here names it. TriggerChain declared it in the game's types and the derived grammar found it — which is the contract this file is built on, so the test now says so out loud instead of listing three verbs. A label, because "After" alone in a palette does not say what waits.
The waves steps rendered as raw call notation — Waves.Begin(Scavengers .Skirmish).Against(Team.Player).From(0.85, 0.15).Intensity(0.3) — while every other step in the same card read as English. Raw notation is the fallback for shapes no phrase covers, and nothing covered these. Begin gets four keys for one sentence. Its chain is order-free, so the phrase key — path plus whichever call came last — is a different string depending on which dials the author turned. The pack stays out of the sentences. It arrives as a noun path and a noun cannot fill a slot, the same limitation Transfer.Units already documents; the DO row's module attribution carries which flavor it is. Intensity CAN fill one now that the game types it as WaveIntensity rather than number — slot names come from param types, and a `number` has nothing to call itself in a sentence. The test asserts the sentences AND the absence of raw notation, since the failure mode here is a phrase key quietly ceasing to match.
Two sentences that named implementation the author never sees. Give read "give group X to the player, mode or no mode". Sharing modes are a lobby concept; a mission author has no modes and cannot have one, so the phrase spent its only clause on the one fact that could not help them choose. What they are actually choosing between is Units and Give, and that distinction survives fine as "share with" against "give outright". After had no unit at all, because its parameter was a bare number and slots are named by their type's alias. The game now declares MissionSeconds, so the box says seconds — derived, not captioned here, which is the point of deriving the grammar in the first place.
The alias gave the parameter a name; nothing said it out loud. Step verbs render through step_phrase_for, which had no arm for After, so the argument fell through to the bare-value path and the card still read AFTER 30 — the type knew the unit and the sentence did not ask. The pill already says AFTER, so the phrase carries only the rest.
Starting serve while the VS Code extension already served that workspace did the worst available thing. The bind failed, the failure was printed to stderr and the return value dropped, and the process carried on: no HTTP, but a full serve loop watching files and writing the SAME editor dir as the one already running. Two writers, one directory, and the panel showed whichever wrote last. It looked like the port was blocked. It was worse than that. The bind now distinguishes "taken" from every other failure, because taken is not an error yet — it is a question about who has it. /whoami answers it: the holder reports the editor dir it serves, so the loser can tell "that is me, in another window" from "that is a different checkout". Same directory, and there is nothing to do — say so and exit 0. Asking for a directory to be served when it is already being served is a request that has been satisfied, not one that failed. Different directory, and step aside onto a free port so two checkouts can be open at once, writing serve_port.json next to the other editor-dir artifacts so a client can find a serve that had to move. Note the mixed-version case: the graceful path needs /whoami at both ends. An older serve holding the port answers 404, which reads as a stranger, so the newcomer moves aside rather than exiting. That is the safe way round — it never mistakes someone else's port for its own.
A preset inside a real modules tree was checked against the trigger
vocabulary and told that Mode is not a verb:
modes/mission.lua:4: unknown statement verb 'Mode'
— the injected environment declares: Spawn/When
Surface resolution only ever composed "trigger". Nothing anywhere composed
"mode", so a preset was handed Spawn/When and no Mode at all — not a
missing declaration, the wrong dictionary. It escaped notice because a tree
with no types dir falls back to the bundled snapshot, which carries both
vocabularies; every test took that path, and every real checkout did not.
Resolution is now per policy, and the policy follows the FILE: a preset and
a trigger file can live in one module and are written in different
languages. The surface cache is keyed by policy for the same reason —
keyed on the types dir alone, whichever kind was seen first would answer
for both.
Mode vocabularies do NOT compose down the requires graph. Every module with
presets declares its own Mode head, so composing merges several and an
arbitrary one wins: missions requires transfer, so a missions preset was
being offered transfer's sharing chain and refused its own Own.
Two neighbours, since they were in the way:
FileKind::of matched the substring "modes/", which is false on Windows,
where the display path arrives with backslashes, and true for any directory
merely ending in the word. It now matches a path COMPONENT, either
separator.
And collect_lua_files globbed **/units.lua, which swept up
modules/transfer/actions/units.lua — the framework's action slot, not a
mission roster — and reported the module's own code as a malformed mission
file. actions/ is now skipped the way spec/ already was.
… gating Editing a mode preset was refused. Every time, silently — the reason went to the serve log and the editor just stopped accepting changes. An edit is only written if the result still passes the recognizer, which is the right rule and the reason the editor cannot corrupt a file. But the gate resolved its grammar with load_near(missions_dir): the trigger vocabulary, taken from the missions directory rather than from the file being edited. So a preset was re-checked against a language it is not written in, Mode came back as an unknown verb, and the write was rejected as leaving the mission subset. Same bug as the one just fixed in check, in the other half of the system. I fixed the read path and did not look at the write path, which is its own lesson: the surface has to follow the file everywhere, not in the place the bug was first noticed. Resolution is now per policy AND per file, so a preset in another module answers to that module rather than to whichever directory serve happens to be rooted at. The test needed two goes. The first passed against the broken code, because a tmpdir with no ancestor types/ dir falls back to the bundled snapshot, which carries both vocabularies — the same masking that let the original bug ship. It now builds a tree whose missions dir really does publish the trigger surface, and asserts up front that the trigger surface does NOT know Mode, so passing means the policy was chosen rather than everything being known.
A click in the form never enters the text editor's undo stack, so the only way back from a mistyped field was to find the file and fix it by hand. The journal that makes stale edits rebasable already recorded every write this serve made; it just never kept what each write replaced, which is the one thing an undo needs and the only thing it was missing. Undo is not a privileged path. The journal hands back an ORDINARY edit intent — old text, inverse span — and it goes through apply_edit_journaled like anything a person clicks: it has to parse, it has to stay inside the grammar, and it is refused if the file moved underneath. Putting something back is still writing, and writing is still gated. It refuses to clobber a foreign write. Undo restores what WE replaced, so if another hand changed the file since, replaying the inverse would quietly discard their work; the journal's before/after hashes already knew how to detect that and it now says so instead. Both the write and its inverse are dropped from the history afterwards, so a second undo reaches the write before it rather than flipping between two states forever. Walked back three edits in a live serve, byte-identical to the original at the end, and a fourth undo changed nothing and said why. Ctrl+Z inside an input is left alone — that is the browser's own undo, and the caret is not what this is for.
…rl+Z Two things the form was missing once it had real content in it. The unit picker offers every unit def in the game — about two thousand, and a form with a dozen pickers was carrying twenty-three thousand option elements. A native select can only be walked, so finding corllt meant scrolling alphabetically past everything before it. In the browser the select is now swapped for an <input list>, which hands filtering to the browser: type any part of a name, including the readable one, and the list narrows. One datalist is shared across every picker, so the page ends up SMALLER than it was with the selects. The markup the view ships is untouched — RmlUi still renders the select it always did, and the dataset attributes come across unchanged, so the delegated change handler and every edit path work exactly as before. Ctrl+Z did not reach the page, which is not surprising in hindsight: the form is an iframe inside a webview, it only sees a keystroke while focused, and VS Code binds Ctrl+Z to its own undo regardless. The keybinding is now declared by the extension with `when: focusedView == barMissionEditor.form` — taken while the panel has focus, left alone everywhere else — and the command posts to serve directly, the same channel the page uses. There is also a plain Undo button in the panel. Keybindings are contested in a way buttons are not, and the one that always works is worth having whatever else is going on.
… not The datalist was the wrong control and it made the picker worse than the select it replaced. A datalist is an autocomplete hint: no list until you start typing, no scrolling, and no way to simply browse what is available. Searchable is not worth losing "openable". This is an actual dropdown. Click it and the whole list appears, scrollable and bounded. Type and it narrows, matching either the def id or the readable name. Arrows move, Enter picks, Escape abandons the search and puts the previous value back — an abandoned search must not be mistaken for a new value, which is the one way a filter box can silently corrupt a field. Rendering is capped at 400 rows: two thousand divs per open is a stutter, and nobody scrolls past four hundred to find a unit — that is what the typing is for. One shared option array across every picker, as before. The view still ships a <select> and RmlUi still renders it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
bar-mission-kit: the mission-DSL tooling behind the in-game editor (BAR PR beyond-all-reason#8380). Built onemmylua_parser— same Lua front-end as the emmylua toolchain.unit_def_name,objective_name,count), decorators, byte spans on every node.check— same walk as a CI validator (nonzero on findings).parse— decorated AST JSON.serve— the live loop over the filesystem: watches triggers, regenerates the artifact (+ trigger-graph DOT), applies span-edit intents gated by the recognizer (parse/grammar failures and stale CAS hashes are refused byte-identically), opens files in VS Code on request.just bar::mission-kit-test / mission-check / mission-ast / mission-serve(serve watches the engine write dir).14 tests. Base:
master.🤖 Generated with Claude Code